security: refuse to write credentials through pre-existing symlinks#94
security: refuse to write credentials through pre-existing symlinks#94garagon wants to merge 3 commits into
Conversation
writeCredentialFile (cli/src/utils/credential-output.ts) was vulnerable to
a TOCTOU exfiltration on shared filesystems. The previous flow was:
fs.access(resolved) // follows symlinks
fs.writeFile(resolved, ..., { mode: 0o600 }) // follows symlinks; mode
// applied only if file is
// created, not if exists
fs.chmod(resolved, 0o600) // follows symlinks; sets perms on TARGET
If the operator runs `spend-request retrieve <id> --output-file <path>
--force` and an attacker on the same filesystem (CI runner, multi-tenant
host, container with shared /tmp) pre-plants a symlink at <path> pointing
at a file the attacker can already read, the attacker:
1. Plants <path> as a symbolic link to /tmp/<readable>.
2. Opens a read fd on /tmp/<readable> while it is world-readable.
3. Operator runs the command: writeFile follows the symlink and writes
the full card credential (PAN, CVV, billing address, valid_until) to
the symlink target.
4. Operator's chmod 0o600 then locks the target down — too late, the
attacker's fd was opened before the chmod and survives it.
Verified end-to-end by a Node reproduction: a fresh fd opened against the
target before the operator's writeCredentialFile call reads the JSON-
encoded credential immediately after the call returns, regardless of the
chmod that follows.
Fix replaces fs.access + fs.writeFile + fs.chmod with a single
fs.open(resolved, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600). The
mode is set at create time. O_NOFOLLOW makes open fail with ELOOP if the
final path component is a symlink. O_EXCL makes open fail with EEXIST if
the file already exists. Force-mode unlinks any pre-existing entry first
(operating on the symlink itself via fs.unlink, not the target via
fs.writeFile), then takes the same atomic-create path.
Tests added to credential-output.test.ts:
- refuses to write through a symbolic link without force
- refuses to write through a symbolic link with force (target untouched)
- TOCTOU race-fail-closed
- 0o600 mode produced even with --force (regression guard for the
removed fs.chmod step)
`pnpm test` is green (117/117 across 11 files). Negative control: with
the implementation reverted, two of the new tests fail because the
symlink target is overwritten by the credential JSON.
raubrey-stripe
left a comment
There was a problem hiding this comment.
Thank you for this, some quick feedback + windows support!
| const resolved = path.resolve(filePath); | ||
|
|
||
| if (!force) { | ||
| // Atomically create the output file with O_EXCL | O_NOFOLLOW so we never |
There was a problem hiding this comment.
Could we cut down on the comment size here to make this more concise?
There was a problem hiding this comment.
Addressed in cc1a135: comment now captures the operational why only. The full attack chain stays in the PR body and tests.
| handle = await fs.open( | ||
| resolved, | ||
| // biome-ignore lint/suspicious/noBitwiseInsideUnaryExpression: standard open(2) flag composition | ||
| constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, |
There was a problem hiding this comment.
It looks like O_NOFOLLOW is not supported on windows.
You can see in a similar vulnerability in a filelock python package here took a Windows specific patch in the "Patches" section.
If you're able we'd very much appreciate an investigation into the windows specific fix as well! But in the meantime, could you document this fix only applies to Posix? We can do a check for constants.O_NOFOLLOW !== 0 and make a decision about whether we support --auth-file as well here 🤔 cc @danhill-stripe
There was a problem hiding this comment.
Addressed in cc1a135: flag is now constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap (O_EXCL still prevents overwriting a pre-existing entry, but no full no-follow semantics there). The proposed Windows fix and the plan to validate it on Windows CI before promising it are in the top-level comment.
Addresses review feedback on PR stripe#94. Gates O_NOFOLLOW as constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap (O_EXCL still prevents overwriting a pre-existing entry, but the path no longer provides full no-follow protection there). Trims in-file comment; the full attack chain stays in the PR body and tests. Drops the biome-ignore directive that pointed at a non-existent rule.
|
Thanks for the review and the GHSA reference. Both points make sense. In-file comment trimmed to the operational "why". Attack chain stays in the PR body and tests. On Windows: agreed. The flag is now For the Windows fix itself, I'd rather not promise it without validating on Windows CI first. The pattern I have in mind is |
raubrey-stripe
left a comment
There was a problem hiding this comment.
Thanks for the updates! Ack on the windows front. One more piece of feedback, let's focus on this first if (force) block. How are you changing the behavior there?
| // Atomically create the credential file so we never write through a | ||
| // pre-existing output path. On POSIX, O_NOFOLLOW refuses to follow a | ||
| // symlink at the final path component; O_EXCL makes create fail if an | ||
| // entry already exists. The 0o600 mode is applied at create time, so | ||
| // the file is owner-only the moment it exists. | ||
| if (force) { | ||
| // fs.unlink operates on the symlink itself rather than its target. | ||
| try { | ||
| await fs.access(resolved); | ||
| await fs.unlink(resolved); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; | ||
| } | ||
| } |
There was a problem hiding this comment.
Sorry one more q - in what context would this be triggered and how has its behavior changed? Why would we want to unlink a symlinked file? Before fs.access was just testing access permissions - why do we want to now potentially unlink? Wouldn't that invalidate the checks below about not following symlinked files?
There was a problem hiding this comment.
Thanks for the close read. Fair point that the prior --force flow didn't fully match the no-follow contract.
The unlink runs only when --force is set, and it clears the path so the subsequent open with O_CREAT | O_EXCL can succeed. For regular files that preserves the prior --force semantics (replace the existing output file), with the credential landing in a fresh 0o600 inode rather than racing a chmod on an already-open file descriptor.
For symlinks specifically, the no-follow guarantee doesn't cover the existing entry. The unlink runs before O_NOFOLLOW can fire, so the protection only covers the race window after the unlink, not the symlink that was already there. The credential never reaches the link target, but --force ends up silently destroying a symlink the operator may not have intended to remove.
Pushed a fix: lstat first, refuse if the final path is a symlink (with or without --force), unlink and recreate only for non-symlink existing files. O_EXCL | O_NOFOLLOW stays as the race defense between the precheck and the atomic create. With this, --force replaces an existing regular output file and rejects everything else.
Thanks again for the careful pass.
There was a problem hiding this comment.
The first part of the change makes sense. Why don't we simply add that check up top and leave the rest of the code unchanged? I'm lost on why we need all the changes below. I'd propose we keep things simply
let existing: Stats | undefined;
try {
existing = await fs.lstat(resolved);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (existing?.isSymbolicLink()) {
throw new Error(
`OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write as symbolic links can open a TOCTOU security threat.`,
);
}
if (!force) {
try {
await fs.access(resolved);
throw new Error(
`OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`,
);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
await fs.writeFile(resolved, JSON.stringify(data, null, 2), {
mode: 0o600,
});
await fs.chmod(resolved, 0o600);
return resolved;
That would make the diff much more reasonable.
There was a problem hiding this comment.
Yep, I agree the current diff is larger than ideal.
The part I'm hesitant to drop is the final open path. The lstat check up front is useful for clearer errors, but it is still only a precheck. If we go back to fs.writeFile, the actual write resolves the path again and follows symlinks.
The other subtle bit is mode: 0o600: it only applies when a new file is created. In the --force path, if the destination already exists, writeFile writes first and the explicit chmod happens after the credential bytes are already on disk.
So I think we need two things:
- Reject an existing symlink at the output path.
- Make the actual credential write create a fresh
0o600file atomically.
Your suggested lstat block covers the first one. The fs.open(O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600) part is what covers the second one.
A concrete --force race I'm trying to avoid:
T0: we lstat and see no file at resolved
T1: another local user creates resolved as a symlink to a file they can already read
T2: because force=true, the fs.access check is skipped
T3: fs.writeFile follows the symlink and writes the credential into that target
T4: fs.chmod follows the symlink and locks the target down to 0o600, but the credential was already written through the symlink
I can definitely trim the implementation and comments so the diff is smaller. My proposal would be:
- keep your
lstatprecheck for clear symlink errors - keep
--forcelimited to replacing existing regular files - keep the final write as atomic
fs.open(... O_EXCL | O_NOFOLLOW, 0o600)plushandle.write
That should keep the PR focused while preserving the part that closes the TOCTOU.
Addresses review feedback on PR stripe#94. The previous revision unlinked any pre-existing entry in --force mode and then ran the atomic open with O_EXCL | O_NOFOLLOW. That cleared the path for the create but also silently destroyed pre-existing symlinks, and the no-follow guarantee only covered the race window after the unlink. This refactor inverts the precheck: lstat first, refuse outright when the final path is a symlink (with or without --force), and only unlink and recreate for non-symlink existing files. O_EXCL | O_NOFOLLOW remains the race defense between the precheck and the atomic create. Tests updated. The without-force symlink case now expects OUTPUT_FILE_SYMLINK and asserts the symlink survives. The with-force case is renamed to make the new contract obvious and gets the same assertions. The dedicated post-unlink-race test is dropped because its premise (force unlinks the symlink) no longer applies; the regular-file race defense is exercised by the existing 0o600 test.
Summary
writeCredentialFile(packages/cli/src/utils/credential-output.ts, added in #67) is vulnerable to a TOCTOU exfiltration on shared filesystems. The previous flow was:If the operator runs
spend-request retrieve <id> --output-file <path> --forceand an attacker on the same filesystem (CI runner, multi-tenant host, container with shared/tmp) pre-plants a symbolic link at<path>pointing at a file the attacker can already read, the attacker can:<path>as a symbolic link to/tmp/<readable>./tmp/<readable>while it is still world-readable.writeFilefollows the symlink and writes the full card credential (PAN, CVC, billing address,valid_until) to the symlink target.fs.chmodthen locks the target down to0o600— too late, the attacker's fd was opened before the chmod and survives it (open fds keep their access mode regardless of subsequent permission changes).Verified end-to-end by a Node reproduction. Pre-fix, the attacker fd reads the JSON-encoded credential immediately after the operator's
writeCredentialFilecall returns:Fix
Replace
fs.access+fs.writeFile+fs.chmodwith a single atomic open:chmodfollow-up step is unnecessary (and the symlink-following bug it introduced is gone).O_NOFOLLOWmakesopenfail withELOOPif the final path component is a symbolic link.O_EXCLmakesopenfail withEEXISTif the file already exists.fs.unlink(operating on the symlink itself rather than the target viafs.writeFile), then takes the same atomic-create path. A re-planted symlink that races into place between the unlink and the open causesopento fail-closed instead of writing through.Post-fix verification:
Tests
packages/cli/src/utils/__tests__/credential-output.test.ts— four new cases:refuses to write through a symbolic link without force— symlink at the target path causesOUTPUT_FILE_EXISTS, and the link target is unchanged.refuses to write through a symbolic link with force—--forceunlinks the symlink and atomically creates a fresh regular file at the path. The original link target is unchanged. The new file is a regular file (not a symlink) with mode0o600.refuses to write through a symlink that races into place between unlink and create— sanity check on the--forcerace-shape. The fail-closed semantics are guaranteed byO_EXCL | O_NOFOLLOW.produces a 0o600 file even when force is set— regression guard for the removedfs.chmodstep. Mode is now set atopen()time via the third argument.Negative control: with the implementation reverted, two of the new tests fail because the symlink target is overwritten by the credential JSON (the existing
it('overwrites existing file with force', …)test still passes — the previous behavior was technically "compatible" with that assertion).Test plan
pnpm vitest run src/utils/__tests__/credential-output.test.tsgreen (8/8)pnpm testgreenpnpm typecheckgreen--force, the call refuses up front. With--force, the symlink is replaced by a fresh regular file at the target path; the original symlink target is unmodified and the attacker fd reads the pre-existing contents.Files